Skip to content

fix(billing,payments,security): harden payment, billing and platform correctness - #1703

Merged
github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
akansnoro:fix/stellar-wave-iteration-10
Oct 16, 2026
Merged

github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
akansnoro:fix/stellar-wave-iteration-10

Conversation

@akansnoro

Copy link
Copy Markdown
Contributor

Closes #1683
Closes #1684
Closes #1685
Closes #1686

Summary

One focused hardening pass across the four Stellar Wave iteration-10 issues. Each
section below names the concrete defect fixed, because in every case the
original code looked correct at a glance and failed in a way that silently
produced wrong money or unsafe behaviour.


#1683 — Improve crypto payment resilience

Reverted transactions were reported as successful. Every on-chain path called
tx.wait() with no arguments and then only asserted that a hash came back. A
receipt with status === 0 means mined but reverted, so a failed payment was
surfaced to the user as confirmed.

New src/services/transactionConfirmation.ts centralises three concerns and is
now used by Superfluid creation, Sablier creation, Sablier approval and ERC-20
approval:

  • requires chain-appropriate confirmation depth (1 on L2, 2 on L1);
  • rejects receipt.status === 0;
  • enforces its own timeout. This mattered: ethers v5's
    ContractTransaction.wait(confirmations) does not forward a timeout argument
    at all, so delegating the bound to it left every contract-based payment able
    to hang forever — and because processQueue is sequential, one stuck
    transaction stalled the whole offline retry queue behind it.

The RPC provider could send a request to the wrong endpoint. The base ethers
provider has one mutable connection.url, and the fallback logic swapped it per
call. getBalancesAcrossChains fans out in parallel, so concurrent calls
interleaved and requests could be routed to a node on a different chain. Access
is now serialised.

Healthy nodes were being evicted from the circuit. Every error counted as a
failure, including legitimate JSON-RPC errors such as reverts and insufficient
funds — a working node answering correctly was treated as unreachable.
isTransportFailure now separates transport problems from application-level
answers. Half-open circuits also required a single success to close; they now
require a threshold, matching the documented behaviour.

Fractional amounts could never pass the spend cap. The check was
ethers.BigNumber.from(amount).gt(limit), and BigNumber.from("12.5") throws.
The throw was swallowed by the surrounding catch, so every fractional amount
was recorded as a generic failure. Replaced with exact fixed-point comparison
(src/utils/decimalAmount.ts) — not parseFloat, which would reintroduce float
error.

Balances used hard-coded decimals. checkBalance assumed USDC ? 6 : 18,
under- or over-reporting any other ERC-20 and triggering spurious fallbacks.
Decimals are now read from the contract. All four raw JsonRpcProvider
constructions also bypassed the timeout/circuit-breaker/fallback layer entirely.

A crash between broadcast and receipt could charge the user twice. The queue
removed an entry only after execution, so an app crash mid-flight left the
item pending and the next pass re-broadcast it. A broadcastedAt marker is now
persisted before the payload reaches the wallet. Items whose outcome is
genuinely unknown are retired with a "needs review" notification rather than
re-sent, and the marker is only cleared for failures that prove nothing was
submitted (wallet rejection). Also added exponential backoff, staleness measured
from the last attempt rather than creation, reference-counted connectivity
listeners, and removal of unhandled rejections that can terminate the app.

Smart fallback ordering was inert. walletStore read attempt.success and
attempt.createdAt, neither of which exists on PaymentAttempt (it has
status and attemptedAt). Every field resolved to undefined, the monitor's
look-back window discarded all attempts, and every method scored a 100% success
rate. Also fixed verifyPaymentMethod leaving isLoading stuck when
verification returned false.


#1684 — Strengthen subscription billing correctness

Editing a line on a discounted invoice increased the amount payable.
createInvoice subtracted the discount but never persisted it, and
updateInvoice recomputed subtotal + tax with no discount at all. New
src/utils/invoiceTotals.ts is the single arithmetic path for both, clamps the
discount to the subtotal so a total can never go negative, and rounds each
component to minor units.

Average invoice amount was Infinity. totalRevenue / paidInvoices || 0
returns Infinity when no invoice is paid, because Infinity is truthy.

MRR was inflated 12× for annual plans. subscriptionStore compared raw
strings and omitted annual entirely, so a 120/year plan reported 120 MRR
instead of 10 — and this is the store that populates stats for the app.
analyticsService and analyticsSlice had the same omission plus a hard-coded
4.345 weeks/month while BILLING_CONVERSIONS uses 52/12. All four now use
src/utils/billingCycleMath.ts.

Revenue was re-recognised in full on every run. In
monthlyRevenueRecognitionJob, a 'partial' recognition was never marked
complete, so the next daily run re-recognised the entire elapsed portion,
compounding daily. The job now recognises only the increment since the last run,
tracking cumulative per-entry progress via a new optional repository method.
When that method is absent it degrades to recognising each entry once at full
amount — slower, but never over-counting. Deferred revenue is clamped at zero.

Group invoices priced the wrong window. generateInvoice derived periodDays
from the period but aggregateCharges computed its cutoff from Date.now(), so
a historical invoice priced a different set of charges than the period it claims.
aggregateCharges now takes an explicit (periodStart, periodEnd).
ownerDiscount was unclamped (a negative value increased the bill, >100 gave a
negative payable amount) and tax was hard-coded to 0.

Usage billing summed all history but labelled it one month.
calculateUsageBilling aggregated the unbounded history array while reporting
the current calendar month, over-billing every account with prior history each
cycle. Period end was also new Date(y, m+1, 0) — midnight starting the last
day, excluding the final day. Additionally, auto idempotency keys built purely
from the event timestamp collided for same-millisecond events, and the second was
silently dropped and never rated.

Also fixed a RunHistory field typo in batchChargeService that made every
batch charge throw on recordRun.


#1686 — Harden platform security

Forgeable pagination cursors → SQL identifier injection. pagination.ts fell
back to the literal 'subtrackr-cursor-secret', published in the repo. Cursors
carry a field that is interpolated into ORDER BY/WHERE, so a known secret
let anyone forge one. The secret is now required in production, signatures are
compared with timingSafeEqual, and any sort/field identifier outside
[A-Za-z0-9_] is rejected.

Forgeable webhook signatures. createApiServer fell back to
'default-dev-key-change-in-prod' for the key that signs every outbound
webhook. Removed; production startup now fails instead.

Weak webhook signature scheme. eventReplayWorker emitted
sha256=<31-bit rolling hash> — labelled SHA-256, not SHA-256. Replaced with a
real keyed HMAC.

API keys hashed with unsalted SHA-256. Now a keyed HMAC, so a leaked key
table cannot be attacked offline. Remains deterministic so lookups still work.

SSRF. Zapier hook targetUrl was stored verbatim and later fetched
server-side, reaching cloud metadata (169.254.169.254), loopback and RFC1918
hosts. New backend/shared/security/outboundUrlGuard.ts validates scheme,
embedded credentials, and private/loopback/link-local/CGNAT/malformed-IP-literal
hosts. Applied at registration and delivery, so already-stored targets are
covered too.

100× rate-limit bypass. The tier came from the client-supplied
x-subscription-tier header, so anyone could request enterprise and get
10,000 req/hr instead of 100. Now resolved from the server-side record, with an
explicit TRUST_TIER_HEADER=true opt-in for trusted-proxy deployments.

Unbounded request bodies. maxBytes was optional and 31 of 32 routes omitted
it, so any route could buffer the whole body into memory before parsing; the
HubSpot webhook accumulated chunks before its signature check. A 256 KiB default
now applies everywhere. The gateway's blanket 10mb JSON limit is reduced to
256kb.

Timing-unsafe secret comparison + unvalidated revalidate path. The
REVALIDATE_SECRET check short-circuited on the first differing byte, and
path went straight into res.revalidate(). Now constant-time and
allowlisted.


#1685 — Expand automated test coverage

New suites for the new modules (transactionConfirmation, decimalAmount,
invoiceTotals, billingCycleMath, outboundUrlGuard) plus extensions to the
transaction-queue and RPC-provider suites covering idempotency, backoff and
circuit-breaker behaviour. Each new test names the regression it locks in.

The existing groupBilling test was updated to the explicit period signature; its
previous assertion encoded the Date.now()-relative window bug.


Deployment notes

Three secrets are now required in production and the process fails to start
without them:

Variable Purpose
CURSOR_HMAC_SECRET Cursor HMAC key
WEBHOOK_SIGNING_KEY Outbound webhook signing key
API_KEY_HASH_PEPPER API key digest pepper

All need random values of at least 16 characters. Outside production an
ephemeral per-process value is generated with a warning — which means cursors
and hashed keys do not survive a restart in dev. Documented in .env.example.

Behaviour change: rate-limit tiers are no longer self-declared by clients.
A deployment behind an authenticating proxy that sets x-subscription-tier
should set TRUST_TIER_HEADER=true and ensure the proxy strips that header from
inbound traffic. Without a server-side tier record, callers resolve to FREE.

Verification

npm run lint, npx tsc --noEmit and npm run format:check were not run
locally — node_modules is not installed in this environment and installing
dependencies was out of scope for this task. The changed src/ files were
manually checked against the configured Prettier rules (printWidth 100,
trailingComma: es5, single quotes) and for unused imports, since
prettier/prettier is configured as an ESLint error.

…correctness

Closes #1683
Closes #1684
Closes #1685
Closes #1686

Crypto payment resilience (#1683)

- Add src/services/transactionConfirmation.ts: a single receipt-confirmation
  path that requires chain-appropriate confirmation depth, rejects
  receipt.status === 0 instead of reporting a revert as success, and enforces
  its own timeout. ethers v5 ContractTransaction.wait() silently ignores a
  timeout argument, so delegating the bound to it left every contract-based
  payment able to hang and stall the retry queue queued behind it.
- Route Superfluid creation, Sablier creation/approval and ERC-20 approval
  through that helper. Remove a duplicate PaymentMethodService import that
  was a redeclaration error.
- rpcProvider: serialise access to the shared connection.url, so concurrent
  calls (getBalancesAcrossChains fans out in parallel) can no longer send a
  request to the wrong endpoint. Only transport failures now count against a
  circuit; a JSON-RPC revert previously evicted healthy nodes. Require a
  success threshold to close a half-open circuit.
- paymentMethodService: compare fractional spend caps with an exact
  fixed-point comparison. BigNumber.from("12.5") throws, and the throw was
  swallowed, so every fractional amount failed the cap check. Read token
  decimals from the contract instead of assuming 6/18, and use resilient
  providers for balance, gas and bytecode reads.
- transactionQueueStore: persist a broadcast marker before handing a payload
  to the wallet, so a crash between submission and receipt cannot replay a
  payment. Retire rather than re-send items whose outcome is unknown, add
  exponential backoff, measure staleness from the last attempt, and remove the
  unhandled rejections that could terminate the app.
- walletStore: read the real PaymentAttempt fields (status/attemptedAt).
  Reading non-existent success/createdAt made every method score a 100%
  success rate, leaving smart fallback ordering permanently inert. Also clear
  isLoading when verification returns false.

Billing correctness (#1684)

- Add src/utils/invoiceTotals.ts and use it in createInvoice/updateInvoice.
  updateInvoice dropped the discount, so editing any line on a discounted
  invoice raised the amount payable; the discount was also never persisted.
  Clamp discounts to the subtotal so a total can never go negative.
- Fix an infinite average invoice amount when invoices exist but none are paid
  (Infinity is truthy, so `x / 0 || 0` returned Infinity).
- Add src/utils/billingCycleMath.ts as the one billing-cycle normaliser.
  subscriptionStore omitted the annual cycle, so a 120/year plan reported 120
  MRR instead of 10; analyticsService and analyticsSlice hard-coded 4.345
  weeks/month while BILLING_CONVERSIONS uses 52/12.
- monthlyRevenueRecognitionJob: recognise only the increment since the last
  run. Partial recognition was never marked complete, so the full elapsed
  portion was re-recognised on every run, compounding daily. Track cumulative
  per-entry progress and clamp deferred revenue at zero.
- groupBilling: price the actual invoiced period rather than "the last N days
  relative to now", clamp ownerDiscount to 0-100, and apply tax.
- InvoiceGenerator: clamp the discount against the subtotal.
- meteringService: bill only usage inside the period being invoiced (it summed
  all lifetime history while labelling it one month), fix the off-by-one
  period end, and stop same-millisecond events from colliding into one
  dropped-by-idempotency event.
- batchChargeService: fix the `RunHistory` field typo that made every batch
  charge throw.

Platform security (#1686)

- pagination: remove the published CURSOR_HMAC_SECRET fallback. A known cursor
  secret let anyone forge a cursor whose `field` is interpolated into SQL.
  Require the secret in production, compare signatures in constant time, and
  reject any sort/field identifier outside [A-Za-z0-9_].
- createApiServer: remove the published WEBHOOK_SIGNING_KEY fallback, which
  allowed forging every outbound webhook signature. Fail closed in production.
- ApiKeyRotationService: hash keys with a keyed HMAC rather than unsalted
  SHA-256, so a leaked key table cannot be attacked offline.
- Add backend/shared/security/outboundUrlGuard.ts and apply it to Zapier hook
  registration, hook delivery and webhook replay, closing an SSRF path to
  cloud metadata and RFC1918 hosts. Validate at both registration and send
  time so already-stored targets are covered too.
- eventReplayWorker: sign with a real HMAC. The previous "sha256=" prefix
  wrapped a 31-bit rolling hash, so replayed deliveries were forgeable.
- server.ts: resolve the rate-limit tier from the server-side record instead of
  the client-supplied x-subscription-tier header, which granted a 100x
  allowance to anyone who asked. Opt back in with TRUST_TIER_HEADER=true
  behind a trusted proxy.
- server.ts: apply a 256 KiB default body cap. 31 of 32 routes read unbounded
  bodies, and the HubSpot webhook accumulated chunks before signature checks.
- gateway/createApiServer: bound express.json() (10mb -> 256kb) and add
  Referrer-Policy and X-DNS-Prefetch-Control.
- revalidate: compare the secret in constant time and allowlist revalidate
  paths instead of passing caller input straight to res.revalidate().

Tests (#1685)

- Add suites for the new modules (transactionConfirmation, decimalAmount,
  invoiceTotals, billingCycleMath, outboundUrlGuard) and extend the transaction
  queue and RPC provider suites to cover the idempotency, backoff and
  circuit-breaker behaviour. Update the group billing test to the explicit
  period signature.
- Document the new required secrets in .env.example.

Deployment notes

- CURSOR_HMAC_SECRET, WEBHOOK_SIGNING_KEY and API_KEY_HASH_PEPPER must be set
  to random values of at least 16 characters in production; startup fails
  otherwise. Outside production an ephemeral value is generated with a warning.
- Rate-limit tiers are no longer self-declared by clients. Deployments behind
  an authenticating proxy that sets x-subscription-tier should set
  TRUST_TIER_HEADER=true and ensure the proxy strips the header from inbound
  traffic.
@github-actions
github-actions Bot merged commit 5afe754 into Smartdevs17:main Oct 16, 2026
13 of 32 checks passed
@gitguardian

gitguardian Bot commented Oct 16, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
38302542 Triggered Zapier Webhook URL 3a87a76 backend/shared/security/tests/outboundUrlGuard.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Comment thread backend/server/createApiServer.ts
Comment thread backend/server/createApiServer.ts
@akansnoro
akansnoro deployed to security-review October 16, 2026 20:15 — with GitHub Actions Active
github-actions Bot pushed a commit that referenced this pull request Oct 16, 2026
…ture) (#1711)

Follow-up to #1703, which is already merged. This clears the two review
threads that were left unresolved there.

## Semgrep: `subtrackr.csrf.state-changing-route-without-csrf` (x2)

The rule fired on these two lines in `backend/server/createApiServer.ts`:

```ts
app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY }));
app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY }));
```

**These are false positives.** Both lines install a *body parser only* — they
declare no handler and change no state. The rule matches `$ROUTER.post($PATH,
$HANDLER)` and excepts only a middleware literally named `csrfProtection`,
`requireAuth` or `verifyWebhookSignature`; it cannot follow the call into
`createStripeBillingRouter` / `createPaddleRouter` in other files.

The routes are genuinely protected, and they fail closed:

| Condition | Response |
| --- | --- |
| Signing secret unconfigured (`verifier === null`) | `503` |
| HMAC over raw bytes does not match | `401` |
| Already-delivered event id | `200 { duplicate: true }`, never dispatched |

The digest is compared with `crypto.timingSafeEqual` inside a 300 s replay
window (`StripeWebhookVerifier.verify` / `PaddleWebhookVerifier.verify`).

### Why this is *not* fixed by adding middleware to the route

The tempting fix — running `verify()` again at the app level — is a silent
production regression. `verify()` calls `remember(eventId)` on success, so a
second call within the same request returns `fresh: false`, and the router
then answers `200 { duplicate: true }` **without ever dispatching the event**.
Every Stripe/Paddle subscription lifecycle update would be dropped.

So this lands as a documented suppression (inline `nosemgrep` plus an entry in
`.semgrep/semgrep-suppressions.yml` with a 6-month expiry), with the
double-verification trap written into the code comment so the next reviewer
does not "fix" it the obvious wrong way.

## GitGuardian: hardcoded Zapier webhook URL

`outboundUrlGuard.test.ts` asserted on:

```ts
validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/')
```

A fixture, not a secret — but the `/hooks/catch/<n>/<n>/` path shape is
indistinguishable from a live hook URL, so it raised a false incident. Replaced
with a non-`/hooks/catch/` path on the same host. The assertion only needs
"public host over https", and realistic Zapier-hostname coverage is kept.

## Verification

- `node --experimental-strip-types --check` passes on both touched TS files.
- The `nosemgrep` justification gate from `security-scan.yml` was reproduced
  locally and passes (every suppression line carries a `reason:`).
- `npm run lint`, `npm run typecheck` and the Jest suites were **not** run —
  `node_modules` is not installed in this environment. CI will cover those.
@akansnoro

Copy link
Copy Markdown
Contributor Author

Not a real secret — remediated in #1711.

GitGuardian id 38302542 · File backend/shared/security/__tests__/outboundUrlGuard.test.ts · R75

The finding is a test fixture, not a credential:

const result = validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/');

It is a URL passed to validateOutboundUrl to assert that a public https
host is accepted — there is no live endpoint, no account, and nothing to
revoke.

The reason it still fired is that the /hooks/catch/<n>/<n>/ path shape is
byte-for-byte what a real Zapier hook URL looks like, so the detector cannot
tell the two apart. Replaced in #1711 with a non-/hooks/catch/ path on the
same host:

const result = validateOutboundUrl('https://hooks.zapier.com/example-endpoint');

The assertion still needs only "public host over https", and keeping the real
hooks.zapier.com hostname preserves the realistic-host coverage. No secret
was ever exposed, so no rotation or history rewrite is required — but the fix
does prevent this recurring and creating noise on every future PR that touches
the file.

Thanks for the catch.

github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
…arately (#1880)

## Context

PR #1703 was squash-merged into `main` as a single commit (`5afe754`), which bundled the fixes for four separate issues:

- #1683 — crypto payment resilience
- #1684 — subscription billing correctness
- #1685 — automated test coverage
- #1686 — platform security

A single squash makes those four changes impossible to review, revert or merge independently: reverting one of them takes the other three with it, and none of them can be traced back to the issue that motivated it.

This pull request is the first step of a revert-and-re-land split. It undoes the squash **mechanically** and changes nothing else, so that the four issues can be re-landed as four separate pull requests.

## What this does

The revert is computed as `main - #1703`: for every file in the #1703 patch, the pre-#1703 content is folded back into the current file. Every change that was merged after #1703 is preserved — only the #1703 hunks are undone. The result is the code as it would have been if #1703 had never been merged.

- 41 files touched by #1703 are reverted (40 of them differ from `main`).
- 11 files that #1703 introduced are removed again.
- `src/utils/invoiceTotals.ts` is kept as a reduced compatibility shim that still exports `isDiscountLineItem` and `resolveLineTotal`, because the itemised-discount work that was merged after #1703 imports them. The full totals module returns in the #1684 pull request.
- `backend/billing/domain/InvoiceGenerator.ts` is unchanged: the discount clamping it gained is required by the later itemised-discount work, so it stays where it is.

This commit carries no behaviour of its own — it only makes the four issues separable again.

## Merge order

This pull request must be merged **first**. The four follow-up pull requests are stacked on top of it, each re-landing exactly one issue:

1. this PR — the revert
2. #1683 — crypto payment resilience
3. #1684 — subscription billing correctness
4. #1686 — platform security
5. #1685 — automated test coverage

Each follow-up pull request is based on the branch of the pull request before it, so its diff contains exactly one issue's work and nothing else. After all five are merged, `main` is identical to its current state.

## Notes

- The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
- `main` already carries two unrelated syntax errors (`backend/server.ts` and `backend/services/batchChargeService.ts`) that predate this work. They are left untouched here.
github-actions Bot pushed a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
## Context

This is the first of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time.

Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request ([the revert](Smartdevs17#1703)) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to:

- #1683 — crypto payment resilience

## What this changes

### Transaction confirmation (`src/services/transactionConfirmation.ts`, new)

Waiting for a transaction is now a first-class operation instead of a fire-and-forget `tx.wait()`:

- chain-aware confirmation thresholds (`requiredConfirmations`), a default timeout, and distinct error types — `TransactionRevertedError`, `TransactionTimeoutError`, `TransactionInsufficientConfirmationsError` — so a caller can tell "the chain rejected it" from "we stopped waiting".
- `isDefinitiveFailure()` separates failures that can never succeed on retry (revert, invalid params) from transport-level problems, which is what the queue below relies on.
- `bestEffortTransactionHash()` recovers the hash when the wallet broadcast succeeded but the receipt was never observed.

### Decimal-safe amounts (`src/utils/decimalAmount.ts`, new)

Token amounts were compared as JavaScript floats. `normalizeDecimal`, `compareDecimalAmounts` and `exceedsLimit` do the comparison on the decimal string instead, so an amount that is exactly at the limit is no longer reported as over the limit because of binary float drift.

### Resilient RPC provider (`src/services/rpcProvider.ts`)

- The circuit breaker now has a `half-open` state with a success threshold, so a node that flaps is not immediately trusted again.
- Only transport-level errors count towards the failure threshold. A legitimate JSON-RPC failure (revert, insufficient funds) means the node answered correctly and must not evict a healthy endpoint.
- Endpoint switching is serialised through a swap chain. The base ethers provider talks to a single mutable `connection.url`, so two concurrent calls (e.g. `getBalancesAcrossChains`, which fans out in parallel) previously interleaved and one of them was sent to a node on a different chain.

### Payment method limits (`src/services/paymentMethodService.ts`)

- The token's own `decimals()` is read instead of assuming "USDC = 6, everything else = 18", which under- or over-reported balances and let a charge through that could not settle.
- `maxSpendPerInterval` is enforced with `exceedsLimit()` instead of a float comparison.
- Balance reads go through the resilient provider.

### Wallet operations (`src/services/walletService.ts`)

Stream creation and token approvals now go through `confirmTransaction()` and fail loudly when the chain does not confirm them, instead of resolving optimistically.

### Transaction queue (`src/store/transactionQueueStore.ts`, `src/store/walletStore.ts`)

- A `broadcastedAt` marker is persisted **before** the transaction is handed to the wallet. A broadcast can succeed on-chain even when the app dies before the receipt is observed, so the next queue pass can tell "never sent" apart from "sent, outcome unknown" and refuse to re-broadcast the latter — which would otherwise charge the user twice.
- Failed attempts are retried with exponential backoff (5 s, doubling, capped at 5 min) and only when the failure is not definitive, so a reverted transaction is not retried forever.

## Merge order

This branch is stacked on the revert pull request and must be merged after it. The remaining pull requests re-land #1684, #1686 and #1685 in that order.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
github-actions Bot pushed a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
## Context

This is the second of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time.

Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to:

- #1684 — subscription billing correctness

## What this changes

### One place that computes invoice totals (`src/utils/invoiceTotals.ts`)

`createInvoice` and `updateInvoice` each rolled their own arithmetic and the two did not agree — `createInvoice` subtracted the discount, `updateInvoice` did not. Editing one line on a discounted invoice therefore silently increased the amount payable, and because the discount was never persisted on the record there was nothing left to subtract on the second pass.

`calculateInvoiceTotals()` is now the single source of truth:

- every component is rounded to minor units (half-up, no binary float drift);
- discount line items (`kind: 'discount'`) are kept out of the subtotal and summed into `discount` instead, so an itemised discount is subtracted exactly once;
- the discount is clamped to the subtotal. Without the clamp a discount larger than the invoice produced a negative tax base and a negative total, which downstream reconciliation reads as a credit owed rather than an over-discounted invoice.

### Invoice service (`src/services/invoiceService.ts`)

Both the create and the update path go through the shared totals function, and the discount is persisted on the record so a second pass subtracts the same amount.

### Billing cycle maths (`src/utils/billingCycleMath.ts`, new)

`monthlyEquivalent`, `yearlyEquivalent`, `isAnnualCycle` and `normalizeBillingCycle` replace the ad-hoc cycle conversions that were scattered across the store and the analytics service, which disagreed about what a weekly or yearly cycle was worth.

### Revenue recognition (`backend/billing/jobs/monthlyRevenueRecognitionJob.ts`, `backend/billing/jobs/index.ts`)

The job now tracks cumulative per-entry recognition progress (`recordEntryProgress`, `recognisedToDate`). Previously each run re-recognised the whole elapsed portion of an entry, so recognised revenue was inflated on every run and the deferred balance grew without bound.

### Metering and group billing (`backend/services/billing/meteringService.ts`, `groupBilling.ts`, `interfaces.ts`)

- Metered billing aggregates over the requested window instead of the last N days from "now", so a period that ended long ago no longer picks up today's usage.
- `aggregateCharges(group, from, to)` takes an explicit window.
- Amounts are rounded to minor units on the way out.

### Analytics (`src/services/analyticsService.ts`, `src/store/slices/analyticsSlice.ts`, `src/store/subscriptionStore.ts`, `backend/services/analytics/subscriptionAnalyticsService.ts`)

Monthly and yearly equivalents now come from the shared billing-cycle helpers, so the dashboard, the store and the backend report the same numbers.

### Batch charges (`backend/services/batchChargeService.ts`)

Uses the shared rounding helper.

### Test (`src/services/__tests__/groupBilling.test.ts`)

Updated for the explicit-window `aggregateCharges` signature.

## Merge order

This branch is stacked on the #1683 pull request and must be merged after it. The remaining pull requests re-land #1686 and #1685 in that order.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
github-actions Bot pushed a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
## Context

This is the third of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time.

Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to:

- #1686 — platform security

## What this changes

### Rate-limit tier cannot be spoofed (`backend/server.ts`, `backend/services/shared/rateLimitingService.ts`)

The caller's tier was read from the client-supplied `x-subscription-tier` header verbatim, so any caller presenting a key could send `X-Subscription-Tier: enterprise` and receive the 10,000 req/hr allowance instead of the FREE tier's 100 req/hr — a 100× rate-limit bypass requiring no privilege at all.

The tier now comes from the server-side record for that key (`rateLimitingService.getTierForApiKey`). The header is honoured only when the explicit `TRUST_TIER_HEADER=true` opt-in is configured, which suits deployments where an authenticating gateway sits in front of this process and strips the header from inbound requests.

### Outbound URL guard (`backend/shared/security/outboundUrlGuard.ts`, new)

`validateOutboundUrl()` rejects URLs that resolve to private, loopback, link-local or otherwise non-routable addresses, including DNS names that resolve to them. `isPrivateHostname()` and `assertSafeIdentifier()` are the building blocks.

Used by:

- `backend/integrations/zapier/ZapierIntegrationService.ts` and `zapierRouter.ts` — the Zapier integration fetches a user-supplied URL, which was a server-side request forgery vector.
- `backend/services/webhook/eventReplayWorker.ts` — the replay worker re-delivers to a stored webhook target.

### Cursor pagination (`backend/services/shared/pagination.ts`, new)

`encodeCursor` / `decodeCursor` sign the cursor payload with a secret, so a client cannot craft one; `buildCursorClause` and `buildPage` provide keyset pagination, and `parseFieldSelection` / `selectFields` let a caller restrict the returned columns.

### API key rotation (`backend/services/auth/domain/ApiKeyRotationService.ts`)

Key rotation records the previous key's state so it can be revoked and audited.

### Webhook body limits and raw payload handling (`backend/server/createApiServer.ts`)

The two provider webhook registrations now cap the raw body size (`limit: MAX_JSON_BODY`), so a webhook POST cannot be used to exhaust memory. The registrations remain body-parser-only — no handler, no state change — and the HMAC verification still happens inside the Stripe and Paddle routers, which fail closed with 503 when unconfigured and 401 on a bad signature.

### Revalidation allow-list (`developer-portal/pages/api/revalidate.ts`)

`path` is caller-supplied and goes straight into `res.revalidate()`, so an arbitrary value let a holder of the secret force revalidation of unrelated routes. Only the documented set of paths is accepted now, and the secret is compared in constant time.

### Gateway (`backend/gateway/index.ts`) and configuration (`.env.example`)

The new toggles (`TRUST_TIER_HEADER`, the pagination cursor secret, the webhook body limit) are documented in `.env.example`.

## Merge order

This branch is stacked on the #1684 pull request and must be merged after it. The last pull request re-lands #1685.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
github-actions Bot pushed a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
## Context

This is the last of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time.

Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to:

- #1685 — automated test coverage

## What this changes

Seven new test suites that cover the behaviour introduced by the other three issues:

| Suite | Covers |
| --- | --- |
| `src/utils/__tests__/decimalAmount.test.ts` | decimal-safe normalisation and comparison, including values exactly at a limit |
| `src/utils/__tests__/billingCycleMath.test.ts` | `monthlyEquivalent` / `yearlyEquivalent` for weekly, monthly and yearly cycles |
| `src/utils/__tests__/invoiceTotals.test.ts` | `calculateInvoiceTotals`: rounding, an itemised discount subtracted exactly once, and the discount clamp |
| `src/services/__tests__/transactionConfirmation.test.ts` | confirmation thresholds, timeouts, reverts and the definitive-failure classification |
| `src/services/__tests__/rpcProvider.test.ts` | circuit-breaker transitions, the half-open success threshold and endpoint failover |
| `src/store/_tests_/transactionQueueStore.test.ts` | the broadcast marker, exponential backoff and the refusal to re-broadcast an unknown-outcome transaction |
| `backend/shared/security/__tests__/outboundUrlGuard.test.ts` | rejection of private, loopback and link-local outbound targets |

## Merge order

This branch is stacked on the #1686 pull request and must be merged **last**: the suites exercise the modules added by the #1683, #1684 and #1686 pull requests, so they only compile and pass once those are in place.

Merging this pull request completes the split. After all five pull requests are merged, `main` is identical to its current state.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
akansnoro added a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue
1683 only. The remainder of the squash is re-landed by the other
branches of the split, so this diff contains this issue's work exclusively.
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Context

This is the first of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time.

#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request ([the revert](#1703)) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to:

- #1683 — crypto payment resilience

## What this changes

### Transaction confirmation (`src/services/transactionConfirmation.ts`, new)

Waiting for a transaction is now a first-class operation instead of a fire-and-forget `tx.wait()`:

- chain-aware confirmation thresholds (`requiredConfirmations`), a default timeout, and distinct error types — `TransactionRevertedError`, `TransactionTimeoutError`, `TransactionInsufficientConfirmationsError` — so a caller can tell "the chain rejected it" from "we stopped waiting".
- `isDefinitiveFailure()` separates failures that can never succeed on retry (revert, invalid params) from transport-level problems, which is what the queue below relies on.
- `bestEffortTransactionHash()` recovers the hash when the wallet broadcast succeeded but the receipt was never observed.

### Decimal-safe amounts (`src/utils/decimalAmount.ts`, new)

Token amounts were compared as JavaScript floats. `normalizeDecimal`, `compareDecimalAmounts` and `exceedsLimit` do the comparison on the decimal string instead, so an amount that is exactly at the limit is no longer reported as over the limit because of binary float drift.

### Resilient RPC provider (`src/services/rpcProvider.ts`)

- The circuit breaker now has a `half-open` state with a success threshold, so a node that flaps is not immediately trusted again.
- Only transport-level errors count towards the failure threshold. A legitimate JSON-RPC failure (revert, insufficient funds) means the node answered correctly and must not evict a healthy endpoint.
- Endpoint switching is serialised through a swap chain. The base ethers provider talks to a single mutable `connection.url`, so two concurrent calls (e.g. `getBalancesAcrossChains`, which fans out in parallel) previously interleaved and one of them was sent to a node on a different chain.

### Payment method limits (`src/services/paymentMethodService.ts`)

- The token's own `decimals()` is read instead of assuming "USDC = 6, everything else = 18", which under- or over-reported balances and let a charge through that could not settle.
- `maxSpendPerInterval` is enforced with `exceedsLimit()` instead of a float comparison.
- Balance reads go through the resilient provider.

### Wallet operations (`src/services/walletService.ts`)

Stream creation and token approvals now go through `confirmTransaction()` and fail loudly when the chain does not confirm them, instead of resolving optimistically.

### Transaction queue (`src/store/transactionQueueStore.ts`, `src/store/walletStore.ts`)

- A `broadcastedAt` marker is persisted **before** the transaction is handed to the wallet. A broadcast can succeed on-chain even when the app dies before the receipt is observed, so the next queue pass can tell "never sent" apart from "sent, outcome unknown" and refuse to re-broadcast the latter — which would otherwise charge the user twice.
- Failed attempts are retried with exponential backoff (5 s, doubling, capped at 5 min) and only when the failure is not definitive, so a reverted transaction is not retried forever.

## Merge order

This is the second of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) was merged, so its diff contains the #1683 work and nothing else.

The remaining three pull requests (#1684, #1686, #1685) are opened on top of this one, in that order, and must be merged after it.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
akansnoro added a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue
1684 only. The remainder of the squash is re-landed by the other
branches of the split, so this diff contains this issue's work exclusively.
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Context

This is the second of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time.

#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to:

- #1684 — subscription billing correctness

## What this changes

### One place that computes invoice totals (`src/utils/invoiceTotals.ts`)

`createInvoice` and `updateInvoice` each rolled their own arithmetic and the two did not agree — `createInvoice` subtracted the discount, `updateInvoice` did not. Editing one line on a discounted invoice therefore silently increased the amount payable, and because the discount was never persisted on the record there was nothing left to subtract on the second pass.

`calculateInvoiceTotals()` is now the single source of truth:

- every component is rounded to minor units (half-up, no binary float drift);
- discount line items (`kind: 'discount'`) are kept out of the subtotal and summed into `discount` instead, so an itemised discount is subtracted exactly once;
- the discount is clamped to the subtotal. Without the clamp a discount larger than the invoice produced a negative tax base and a negative total, which downstream reconciliation reads as a credit owed rather than an over-discounted invoice.

### Invoice service (`src/services/invoiceService.ts`)

Both the create and the update path go through the shared totals function, and the discount is persisted on the record so a second pass subtracts the same amount.

### Billing cycle maths (`src/utils/billingCycleMath.ts`, new)

`monthlyEquivalent`, `yearlyEquivalent`, `isAnnualCycle` and `normalizeBillingCycle` replace the ad-hoc cycle conversions that were scattered across the store and the analytics service, which disagreed about what a weekly or yearly cycle was worth.

### Revenue recognition (`backend/billing/jobs/monthlyRevenueRecognitionJob.ts`, `backend/billing/jobs/index.ts`)

The job now tracks cumulative per-entry recognition progress (`recordEntryProgress`, `recognisedToDate`). Previously each run re-recognised the whole elapsed portion of an entry, so recognised revenue was inflated on every run and the deferred balance grew without bound.

### Metering and group billing (`backend/services/billing/meteringService.ts`, `groupBilling.ts`, `interfaces.ts`)

- Metered billing aggregates over the requested window instead of the last N days from "now", so a period that ended long ago no longer picks up today's usage.
- `aggregateCharges(group, from, to)` takes an explicit window.
- Amounts are rounded to minor units on the way out.

### Analytics (`src/services/analyticsService.ts`, `src/store/slices/analyticsSlice.ts`, `src/store/subscriptionStore.ts`, `backend/services/analytics/subscriptionAnalyticsService.ts`)

Monthly and yearly equivalents now come from the shared billing-cycle helpers, so the dashboard, the store and the backend report the same numbers.

### Batch charges (`backend/services/batchChargeService.ts`)

Uses the shared rounding helper.

### Test (`src/services/__tests__/groupBilling.test.ts`)

Updated for the explicit-window `aggregateCharges` signature.

## Merge order

This is the third of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 pull request ([#1881](#1881)) were merged, so its diff contains the #1684 work and nothing else.

The remaining two pull requests (#1686, #1685) are opened on top of this one, in that order, and must be merged after it.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
akansnoro added a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue
1686 only. The remainder of the squash is re-landed by the other
branches of the split, so this diff contains this issue's work exclusively.
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Context

This is the third of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time.

#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to:

- #1686 — platform security

## What this changes

### Rate-limit tier cannot be spoofed (`backend/server.ts`, `backend/services/shared/rateLimitingService.ts`)

The caller's tier was read from the client-supplied `x-subscription-tier` header verbatim, so any caller presenting a key could send `X-Subscription-Tier: enterprise` and receive the 10,000 req/hr allowance instead of the FREE tier's 100 req/hr — a 100× rate-limit bypass requiring no privilege at all.

The tier now comes from the server-side record for that key (`rateLimitingService.getTierForApiKey`). The header is honoured only when the explicit `TRUST_TIER_HEADER=true` opt-in is configured, which suits deployments where an authenticating gateway sits in front of this process and strips the header from inbound requests.

### Outbound URL guard (`backend/shared/security/outboundUrlGuard.ts`, new)

`validateOutboundUrl()` rejects URLs that resolve to private, loopback, link-local or otherwise non-routable addresses, including DNS names that resolve to them. `isPrivateHostname()` and `assertSafeIdentifier()` are the building blocks.

Used by:

- `backend/integrations/zapier/ZapierIntegrationService.ts` and `zapierRouter.ts` — the Zapier integration fetches a user-supplied URL, which was a server-side request forgery vector.
- `backend/services/webhook/eventReplayWorker.ts` — the replay worker re-delivers to a stored webhook target.

### Cursor pagination (`backend/services/shared/pagination.ts`, new)

`encodeCursor` / `decodeCursor` sign the cursor payload with a secret, so a client cannot craft one; `buildCursorClause` and `buildPage` provide keyset pagination, and `parseFieldSelection` / `selectFields` let a caller restrict the returned columns.

### API key rotation (`backend/services/auth/domain/ApiKeyRotationService.ts`)

Key rotation records the previous key's state so it can be revoked and audited.

### Webhook body limits and raw payload handling (`backend/server/createApiServer.ts`)

The two provider webhook registrations now cap the raw body size (`limit: MAX_JSON_BODY`), so a webhook POST cannot be used to exhaust memory. The registrations remain body-parser-only — no handler, no state change — and the HMAC verification still happens inside the Stripe and Paddle routers, which fail closed with 503 when unconfigured and 401 on a bad signature.

### Revalidation allow-list (`developer-portal/pages/api/revalidate.ts`)

`path` is caller-supplied and goes straight into `res.revalidate()`, so an arbitrary value let a holder of the secret force revalidation of unrelated routes. Only the documented set of paths is accepted now, and the secret is compared in constant time.

### Gateway (`backend/gateway/index.ts`) and configuration (`.env.example`)

The new toggles (`TRUST_TIER_HEADER`, the pagination cursor secret, the webhook body limit) are documented in `.env.example`.

## Merge order

This is the fourth of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 ([#1881](#1881)) and #1684 ([#1882](#1882)) pull requests were merged, so its diff contains the #1686 work and nothing else.

The last pull request (#1685) is opened on top of this one and must be merged after it.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
akansnoro added a commit to akansnoro/SubTrackr that referenced this pull request Oct 22, 2026
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue
1685 only. The remainder of the squash is re-landed by the other
branches of the split, so this diff contains this issue's work exclusively.
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Context

This is the last of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time.

#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to:

- #1685 — automated test coverage

## What this changes

Seven new test suites that cover the behaviour introduced by the other three issues:

| Suite | Covers |
| --- | --- |
| `src/utils/__tests__/decimalAmount.test.ts` | decimal-safe normalisation and comparison, including values exactly at a limit |
| `src/utils/__tests__/billingCycleMath.test.ts` | `monthlyEquivalent` / `yearlyEquivalent` for weekly, monthly and yearly cycles |
| `src/utils/__tests__/invoiceTotals.test.ts` | `calculateInvoiceTotals`: rounding, an itemised discount subtracted exactly once, and the discount clamp |
| `src/services/__tests__/transactionConfirmation.test.ts` | confirmation thresholds, timeouts, reverts and the definitive-failure classification |
| `src/services/__tests__/rpcProvider.test.ts` | circuit-breaker transitions, the half-open success threshold and endpoint failover |
| `src/store/_tests_/transactionQueueStore.test.ts` | the broadcast marker, exponential backoff and the refusal to re-broadcast an unknown-outcome transaction |
| `backend/shared/security/__tests__/outboundUrlGuard.test.ts` | rejection of private, loopback and link-local outbound targets |

## Merge order

This is the fifth and last pull request in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 ([#1881](#1881)), #1684 ([#1882](#1882)) and #1686 ([#1883](#1883)) pull requests were merged, so its diff contains the #1685 work and nothing else.

The suites exercise the modules added by the #1683, #1684 and #1686 pull requests, so this pull request has to come last.

Merging it completes the split. After all five pull requests are merged, `main` is identical to its state before the split.

The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.

This branch was successfully deployed

1 active deployment
security-review — 3a87a766 Deployed Oct 16, 2026 by akansnoro via Security Dashboard #1353
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants